閉包最常用的情況是在巢狀函式裏(即是函式裏的函式)。它最強大的功能就是能夠把函式裏的資料狀態保存下來。一般而言,函式一旦被執行掉後,函式內的資料就會被銷毀,從而釋放記憶體空間。
但是閉包就能避免以上情況,引用Kuro大大這篇文章,他指出「閉包」是當我們回傳內層函式時,除了回傳函式本身,也會回傳內層函式當時環境的變數值,以及記下當時的環境。因此,我可以利用這個特性來保存我們想要的資料。
題外話,雖然巢狀函式(即是函式裏的函式)才會常常用到閉包的概念。然而,這不代表只有巢狀函式才會產生閉包。事實上,只要是函式,就會產生閉包。
回到重點,這篇文章會整理以下知識:
為什麼要提及範圍鏈?以上提過,當回傳內層函式時,也會一併回傳它當時環境的變數值,記下當時的環境。「環境」這一詞,就是與範圍鏈有關。範圍鏈的意思是:
簡單例子:
function funcA(){
var num = 10;
function funcB(){
//內層沒有num,往上層找num
var x = 100;
console.log(num) //10
}
funcB()
//外層不能訪問內層
console.log(x) //x is not defined
}
funcA()
重溫完範圍鏈的概念後,就來看看閉包的意思,以下例子所做的事:
inner
存在一個變數a
a
var name = '全域name'
function outer(){
name = '區域name'
function inner(){
console.log(name)
}
return inner
}
const a = outer()
a() //區域name
最後結果會回傳區域name
,並非全域name
。為什麼?我們不是在全域呼叫inner
內層函式(即是變數a
)嗎?
然而,範圍鏈在建立函式的當下已經被定義好,而非在呼叫時定義,所以即使在全域呼叫它,它的值仍然是根據它當時在函式裏的範圍鏈而定。
在inner
裏的name
,會取得外層的name
,即是區域name
,這個範圍鏈已經定義好了。即使你之後在全域呼叫此內層函式,它裏面的name
還是會指向outer
裏的name
(區域name
)
總結以上例子,當我們把內層函式存在變數裏,並呼叫該變數,即是呼叫該內層函式。這時候,它是依照當時在函式時,定義好的範圍鏈去執行。
正因為它是依照當時的範圍鏈執行,所以我們可以說,當回傳內層函式時,除了回傳內層函式自己本身,也同時取得內層函式當時環境的變數值,以及記下當時的環境,這就是函包的用處。
以下例子很經典,經常被用來解釋閉包。我們預期會得出0,1,2,但卻得出3,3,3:
function func(){
var arr = [];
for(var i=0; i<3; i++){
arr.push( function(){
console.log(i);
})
}
return arr;
}
var result = func();
result[0]()
result[1]()
result[2]()
//回傳3,3,3
迴圈跑3次,每次把一個匿名函式推到arr
裏,所以arr
現在是:
[function(){console.log(i);}, function(){console.log(i);}, function(){console.log(i);}]
當時在func()
函式裏時,這些匿名函式裏並沒有i
這個變數,所以往上查找全域的i
,並取得全域i
的數值,之後才被推到arr
陣列裏。即使現在我們在全域一一執行它們,這裏是i
也會是全域i
數值(沿用當時已定義好的範圍鏈),所以它們都會吐出3
這數值。
要解決這個問題,可以用立即函式去把每次跑迴圈的i
數值帶進去匿名函式裏:
function func(){
var arr = [];
for(var i=0; i<3; i++){
(function(i){
arr.push( function(){
console.log(i);})
})(i)
}
return arr;
}
var result = func();
result[0]()
result[1]()
result[2]()
//回傳0,1,2
另外一個最常見做法就是把var i=0
改成let i=0
,使i
只是存活在{}
中,這裏就不多解釋。
當我在閱讀Kuro大大的文章時,看到他文章底下網友的提問,也一度使自己的腦袋打結,發現自己沒有把閉包的概念理解清楚,所以也想在此記錄一下自己想不通的地方。
例子是做一個累加器,每次執行函式,就會累加一次。以下例子中,以執行3次為例,預期結果是1,2,3
以下為Kuro大大的正確例子:
function counter(){
var count = 0;
return function(){
return ++count
}
}
var countFunc = counter();
console.log(countFunc()); //1
console.log(countFunc()); //2
console.log(countFunc()); //3
該位網友留言裏的例子,他只能印出1:
function counter(){
var count = 0;
function innerCounter(){
return ++count;
}
var tmpV = innerCounter();
// console.log( '>>>' + tmpV ); (這行無關重要所以先註解起來)
return tmpV;
}
console.log( counter() ); // 1
console.log( counter() ); // 1
console.log( counter() ); // 1
Kuro做法:
把回傳的內層函式存放到變數 > 呼叫變數(呼叫內層函式) > 得出數值
網友做法:
呼叫外層函式 > 把內層函式結果存到變數 > 回傳變數 > 得出數值
這裏的關鍵是有沒有執行var count = 0
。網友的做法中,雖然有把內層函式innerCounter
的結果儲存起來並且回傳,可是,當他第1次打後再呼叫counter
時,他每次都會執行var count = 0
,使count
數值再次歸零,所以每次都只會回傳1。
相反,Kuro的做法是直接接呼叫內層函式innerCounter
,不會呼叫外層函式counter
而導致每次都會跑到var count = 0
。每次呼叫內層函式時,因為之前已定義好innerCounter
內層的count
會從外層count
變數取得,而且,內層的count
是會改變外層的count
。所以每次累加後,外層counter
的count
都會被+1,從而達成累加效果。
如果我們在中間加多一行程式碼就更清楚:
function counter(){
var count = 0;
return function(){
console.log('上一次的數目:' + count)
return ++count
}
}
var countFunc = counter();
console.log(countFunc()); //1
console.log(countFunc()); //2
console.log(countFunc()); //3
上面提及到,我們透過呼叫內層函式,保存到外層函式的資料狀態。我們可以應用此原理到一些需要預設值的情況,範例如下:
function calculate(init){
//預設價錢是100
var price = init || 100;
return function(num){
price += num;
return price;
}
}
var item1 = calculate(500);
var item2 = calculate(2000);
var item3 = calculate();
console.log(item1(50)) //550
console.log(item2(1000)) //3000
console.log(item3(10)) //110
此例可見,外層的資料其實是可變的(傳入參數init
)。這3個item
的數值不會影響對方。每次再呼叫內層函式,內層price
會取得外層price
的值,這就是建立calculate
函式時已經定義好的範圍鏈。
以下的部分,就是3個獨立的函式,它們之間並無關係:
var item1 = calculate(500);
var item2 = calculate(2000);
var item3 = calculate();
之後,我們再各自呼叫這3個變數(即是3個獨立存在的函式),並呼叫內層函式,然後就是在跑我們之前討論過的流程了。
這就是函式工廠的做法。這裏的函式就如工廠一樣,我們可以傳進不同的原材料,得出不同的產品,但工作流程是一樣的。
給它不同原材料(值) > 做一樣的流程 > 生產不同結果
另一個更彈性的做法就是私有化,意思是在函式裏放有多種方法任由自己選擇,使我們可以選擇用某個方法來產生值。例如:
function calculate(init){
//預設價錢是100
var price = init || 100;
return {
add: function(num){
return price += num
},
deduct: function(num){
return price += num
}
}
}
var item1 = calculate(500);
var item2 = calculate(2000);
console.log(item1.add(100)); //600
console.log(item1.add(100)); //700
console.log(item2.deduct(500)); //2500
以上例子中,我們把多種方法放到一個物件裏,可以任意選擇需要的方法來產生值,這就是私有化的做法。
上面提及的兩種做法,好處是可以提高一個函式的可重用性,使我們不用建立多餘的函式,導致程式碼過分冗長。
重新認識 JavaScript: Day 19 閉包 Closure
闭包
從ES6開始的JavaScript學習生活
JS 原力覺醒 Day08 - Closures
JS 核心篇(六角學院)